在講故事之前,在上篇結尾有提到: SOLID,那什麼是 SOLID 呢?
SOLID 是物件導向設計原則的五個英文縮寫,五個原則,每個縮寫都是一個單字,說明軟體開發風格
亦代表,就是大師走過的坑,需要好好學習呀~
由軟體工程大師 Robert C. Martin(Uncle Bob)提出:
S — Single Responsibility Principle (單一職責原則)
O — Open-Closed Principle (開放封閉原則)
L — Liskov Substitution Principle (里氏替換原則)
I — Interface Segregation Principle (介面隔離原則)
D — Dependency Inversion Principle (依賴反轉原則)
第一次看到這個的時候頭就痛了(?
而當這些原則能妥善應用在專案時,能大幅提升軟體的可維護性和擴展性,讓我們寫出更專業的程式碼
如同伊果大大說道
單一職責原則就是很好的檢驗方式,讓我們可以用來判斷這個類別是否足夠健康
那我們來看第一個 S 單一職責原則 Single Responsibility Principle 的定義吧(簡稱 SRP )
SRP 啊,通常在界定上最為模糊,因為針對定義 Uncle Bob 就解釋許多次,也經過多年的演進
後續在 Uncle Bob 的書籍 CleanArchitecture 說明
A module should have one, and only one, reason to change.
「一個模組應該只有一個被改變的原因」
我也引用Fred聊聊SOLID設計原則的說明
「將不同意圖、不同使用情境、不同需求、不同修改時機的功能劃分為各自獨立的『職責』」
誰提需求 → 這群需求構成一個職責 → 一個職責對應一個 class → 職責分乾淨了,修改才會局部化,風險才會降低
好我們回到故事
阿柴翻開小黃留下的程式碼,想先摸清楚這套系統的底細。小黃當初把「算金額、印收據、傳 Line 通知老闆、存進資料庫」四件事,全部塞進同一支 OrderService
跑是跑得動,阿柴看了看,心想「古人有說,能跑的程式碼就先別動」,就先去忙別的事
兩週後,老闆小黑說:「收據格式可以換一下嗎?我想加上店名跟日期」
阿柴:「好,沒問題小改而已」
改完之後,Line 通知突然壞掉了
阿柴:「???」
阿柴打開 log 一看,是印收據那段程式碼要抓店家資訊,結果阿柴發現 store 是 null,一取用 store.Name 就直接噴出例外,整個 method 卡在那裡中斷
這時候先別緊張,深呼吸
public class OrderService
{
public async Task ProcessOrder(Order order)
{
// A.計算金額
decimal total = order.Items.Sum(i => i.Price * i.Quantity);
// B.印出收據
var store = GetStoreInfo();
Console.WriteLine($"=== {store.Name} 柴咖啡收據 ==="); // 老闆要求加店名,但 store 可能是 null,這裡直接噴 NullReferenceException
Console.WriteLine($"日期:{order.Date}"); // 老闆要求加日期
Console.WriteLine($"小計:{total}");
// C.傳 Line 通知老闆 ← 因為上面已經炸了,這行永遠不會被執行到
var client = new HttpClient();
await client.PostAsync("https://notify-api.line.me/...", new StringContent($"新訂單!金額:{total}"));
// D.存進資料庫
var db = new AppDbContext();
db.Orders.Add(order);
db.SaveChanges();
}
}
這時候也幸好有出現 Error,我們才知道這是個「不健康」的類別
一個 method,四件事:計算、印收據、通知、存資料庫
看起來很方便,問題是——這四件事,各自都有各自的「改變理由」:
現在店裡就老闆跟員工兩個人,這四件事目前都是老闆一個人在喊,嚴格說,還稱不上「四個不同的業務關聯方」
但即使是同一個人,這四件事「多久變一次」「牽涉的風險」也不同
收據格式可能一年才改一次
促銷折扣可能一週要調好幾次
通知規則單純是老闆自己的使用習慣,想改就改
資料庫欄位或廠商通常是系統要擴充時才會動,頻率最低,但一旦要動,風險也最高
不同的變動頻率、不同的風險等級,本身就是分開處理的理由
每一次改動,都有可能在對其他功能埋地雷
一個 class,應該只有一個改變的理由
換句話說,一個 class 只負責一個職責,當需求變動時,只有跟它有關的那個理由會讓它需要改
// 只負責計算
public class OrderCalculator
{
public decimal Calculate(Order order)
=> order.Items.Sum(i => i.Price * i.Quantity);
}
// 只負責印收據
public class ReceiptPrinter
{
public void Print(Order order, decimal total)
{
PrintHeader(order);
PrintItems(order);
PrintTotal(total);
}
private void PrintHeader(Order order)
{
var store = GetStoreInfo();
Console.WriteLine($"=== {store.Name} 柴咖啡收據 ===");
Console.WriteLine($"日期:{order.Date}");
}
private void PrintItems(Order order)
{
foreach (var item in order.Items)
{
Console.WriteLine($" {item.Price * item.Quantity}");
}
}
private void PrintTotal(decimal total)
{
Console.WriteLine($"小計:{total}");
}
}
// 只負責通知
public class LineNotifier
{
public async Task Notify(decimal total)
{
var client = new HttpClient();
await client.PostAsync("https://notify-api.line.me/...", new StringContent($"新訂單!金額:{total}"));
}
}
// 只負責存資料
public class OrderRepository
{
public void Save(Order order)
{
var db = new AppDbContext();
db.Orders.Add(order);
db.SaveChanges();
}
}
// OrderService 重構後的模樣
public class OrderService
{
private readonly OrderCalculator _calculator = new();
private readonly ReceiptPrinter _printer = new();
private readonly LineNotifier _notifier = new();
private readonly OrderRepository _repository = new();
public async Task ProcessOrder(Order order)
{
// 1. 計算
var total = _calculator.Calculate(order);
// 2. 列印
_printer.Print(order, total);
// 3. 非同步發送通知
await _notifier.Notify(total);
// 4. 存檔
_repository.Save(order);
}
}
現在老闆要換收據格式,只動 ReceiptPrinter,Line 通知不會受影響,資料庫也沒事
下次不管是接手別人留下的程式碼,還是叫 AI 幫你生程式碼,都可以問自己:
這個 class 有幾個改變的理由? 超過一個,或許就該拆
例如前面的 OrderService,收據格式、通知規則、促銷折扣、資料庫欄位,四件事各自的變動頻率、風險都不一樣
混在同一個 class 裡,任何一個變動都可能拖累其他功能,這可能就是個訊號
可以拆成 計算、列印、通知、存資料 了
如果需求 A 改了,會不會意外影響功能 B? 會的話,就是職責混在一起了
這個 class 的名字能說清楚它做什麼嗎? 叫 OrderService 但做了四件事,這樣就不健康
SRP 不是說「一個 class 只能有一個 method」,這樣會拆得很辛苦,也是矯枉過正
判斷標準是:這個 class 裡的所有邏輯,是不是為了同一個目的而存在?
ReceiptPrinter 裡面可以有多個 method: PrintHeader()、PrintItems()、PrintTotal(),它們都為了「印收據」這一件事服務,這樣是沒問題的
怕的是一個 class 同時在做「印收據」和「通知老闆」,因為這兩件事不是同一個理由,就會是個警訊
Katsuobushi 大大曾列舉 SRP 以下的優點
減少程式碼之間的依賴,讓該模組的變更將會來自同一個業務需求,因業務耦合造成的維護問題減少,進而也減少需求更改而影響到另一個業務需求的情況
程式中每個部分都與自己實作的功能相關,提高內聚力,提高可重用性
一個模組只包含與自身相關的邏輯,降低程式複雜性,提高可讀性、可維護性
阿柴也透過 SRP 的觀念,依序將 code 重構成安全的樣子,可喜可賀,可口可樂
下一篇,我們繼續看 SOLID 的第二條:O 開放封閉原則
柴咖啡要開始加新口味了
延伸閱讀文章:
軟體架構設計原則 1 - SRP 單一職責原則
The Single Responsibility Principle
Single-responsibility principle